diff options
Diffstat (limited to 'app/api/auth/[...nextauth]/saml/utils.ts')
| -rw-r--r-- | app/api/auth/[...nextauth]/saml/utils.ts | 175 |
1 files changed, 125 insertions, 50 deletions
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts index 7dfe9581..73c00bf6 100644 --- a/app/api/auth/[...nextauth]/saml/utils.ts +++ b/app/api/auth/[...nextauth]/saml/utils.ts @@ -6,11 +6,12 @@ import { import { getSPMetadata, } from "@/lib/saml/sp-metadata"; +import { debugLog, debugError, debugSuccess, debugProcess, debugMock } from '@/lib/debug-utils'; export interface SAMLProfile { nameID?: string; nameIDFormat?: string; - attributes?: Record<string, string[]>; + attributes?: Record<string, string | string[]>; // 문자열 또는 배열 모두 지원 [key: string]: unknown; } @@ -100,6 +101,12 @@ export async function createAuthnRequest(): Promise<string> { "use server"; console.log("SSO STEP 2: Create AuthnRequest"); + + // Mock IdP 모드 체크 + if (process.env.SAML_MOCKING_IDP === 'true') { + debugMock("Mock IdP mode enabled - simulating SAML response"); + return createMockSAMLFlow(); + } try { const config = createSAMLConfig(); @@ -170,7 +177,7 @@ export async function createAuthnRequest(): Promise<string> { ); try { - const zlib = require("zlib"); + const zlib = await import("zlib"); const decompressed = zlib .inflateRawSync(base64DecodedBuffer) .toString("utf-8"); @@ -182,9 +189,9 @@ export async function createAuthnRequest(): Promise<string> { // XML 구조 분석 const xmlLines = decompressed .split("\n") - .filter((line) => line.trim()); + .filter((line: string) => line.trim()); console.log("XML 구조 요약:"); - xmlLines.forEach((line, index) => { + xmlLines.forEach((line: string, index: number) => { const trimmed = line.trim(); if ( trimmed.includes("<saml") || @@ -224,7 +231,7 @@ export async function createAuthnRequest(): Promise<string> { ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}` ); } catch (inflateError) { - console.log("❌ Deflate 압축 해제 실패:", inflateError.message); + console.log("❌ Deflate 압축 해제 실패:", (inflateError as Error).message); console.log( " 원본 바이너리 데이터 (hex):", base64DecodedBuffer.toString("hex").substring(0, 100) + "..." @@ -232,11 +239,11 @@ export async function createAuthnRequest(): Promise<string> { } } } catch (decodeError) { - console.log("❌ Base64 디코딩 실패:", decodeError.message); + console.log("❌ Base64 디코딩 실패:", (decodeError as Error).message); } } } catch (analysisError) { - console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.message); + console.log("⚠️ SAML AuthnRequest 분석 중 오류:", (analysisError as Error).message); } console.log("✅ SAML AuthnRequest URL generated:", { @@ -271,9 +278,15 @@ export async function validateSAMLResponse( timestamp: new Date().toISOString(), }); + // Mock IdP 모드 체크 + if (process.env.SAML_MOCKING_IDP === 'true') { + debugMock("Mock IdP mode - returning mock SAML profile"); + return createMockSAMLProfile(samlResponse); + } + // 실제 SAML 검증 수행 (기본값) console.log( - "🔐 Using Real SAML validation (SAML_USE_MOCKUP=false or not set)" + "🔐 Using Real SAML validation (SAML_MOCKING_IDP=false or not set)" ); try { @@ -293,11 +306,11 @@ export async function validateSAMLResponse( throw new Error("No profile returned from SAML validation"); } - // SAMLProfile 형태로 변환 + // SAMLProfile 형태로 변환 (타입 안전성 확보) const samlProfile: SAMLProfile = { - nameID: profile.nameID, - nameIDFormat: profile.nameIDFormat, - attributes: profile.attributes || {}, + nameID: profile.nameID as string | undefined, + nameIDFormat: profile.nameIDFormat as string | undefined, + attributes: profile.attributes as Record<string, string | string[]> | undefined, }; console.log("✅ Real SAML Profile validated successfully:", { @@ -332,71 +345,133 @@ export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser { attributes: profile.attributes, }); + // SAML attributes는 문자열 또는 배열 형태일 수 있음 + const extractAttributeValue = (key: string): string | undefined => { + const value = profile.attributes?.[key]; + if (Array.isArray(value)) { + return value.length > 0 ? value[0] : undefined; + } + return typeof value === 'string' ? value : undefined; + }; + // 기본적으로 nameID를 사용하거나 attributes에서 추출 - const id = - profile.nameID || - profile.attributes?.uid?.[0] || - profile.attributes?.employeeNumber?.[0] || - ""; - const email = - profile.attributes?.email?.[0] || - profile.attributes?.mail?.[0] || - profile.nameID || - ""; - // UTF-8 이름 처리 개선 - let name = - profile.attributes?.displayName?.[0] || - profile.attributes?.cn?.[0] || - profile.attributes?.name?.[0] || - (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0] - ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0] - : "") || - ""; + const id = profile.nameID || extractAttributeValue('id') || extractAttributeValue('sub'); + const email = extractAttributeValue('email') || extractAttributeValue('emailAddress'); + const name = extractAttributeValue('name') || extractAttributeValue('displayName') || extractAttributeValue('cn'); + + // 필수 필드 검증 + if (!id) { + throw new Error('SAML profile missing required field: id (nameID)'); + } + if (!email) { + throw new Error('SAML profile missing required field: email'); + } + if (!name) { + throw new Error('SAML profile missing required field: name'); + } // UTF-8 문자열 정규화 및 검증 - if (name && typeof name === "string") { - name = name.normalize("NFC").trim(); - - // 한글이 깨진 경우 감지 및 로그 - const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name); - if (hasInvalidChars) { - console.warn("⚠️ Invalid UTF-8 characters detected in name:", { - originalName: name, - charCodes: [...name].map((c) => c.charCodeAt(0)), - hexDump: [...name] - .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")) - .join(""), - }); - } + const normalizedName = name.normalize("NFC").trim(); + + // 한글이 깨진 경우 감지 및 로그 + const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(normalizedName); + if (hasInvalidChars) { + console.warn("⚠️ Invalid UTF-8 characters detected in name:", { + originalName: name, + normalizedName, + charCodes: [...normalizedName].map((c) => c.charCodeAt(0)), + hexDump: [...normalizedName] + .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""), + }); } - // 회사 정보는 SSO 로그인 시 없음 + // 회사 정보는 SSO 로그인 시 없음 (evcp 도메인) const companyId = undefined; const techCompanyId = undefined; const domain = 'evcp'; - const user = { + const user: SAMLUser = { id, email, - name: name.trim(), + name: normalizedName, companyId, techCompanyId, domain, }; - console.log("👤 Mapped user object:", user); + console.log("👤 Mapped user object:", JSON.stringify(user)); return user; } +// Mock SAML 플로우 생성 (테스트용) +function createMockSAMLFlow(): string { + debugMock("Creating mock SAML flow..."); + + // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`; + + debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl); + + return mockIdpUrl; +} + +// Mock SAML Profile 생성 (테스트용) +function createMockSAMLProfile(samlResponse: string): SAMLProfile { + console.log("🎭 Creating mock SAML profile from response..."); + + try { + // SAML Response가 우리가 생성한 Mock인지 확인 + const decodedXML = Buffer.from(samlResponse, 'base64').toString('utf-8'); + const isMockResponse = decodedXML.includes('MockIdP'); + + if (!isMockResponse) { + console.warn("⚠️ Mock mode enabled but received non-mock SAML Response"); + } + + console.log("🎭 Mock SAML XML preview:", decodedXML.substring(0, 200) + "..."); + } catch (error) { + console.warn("⚠️ Could not decode SAML Response for mock analysis:", (error as Error).message); + } + + // Mock SAML Profile 반환 (실제 SAML Response와 일치하도록 문자열 형태) + const mockProfile: SAMLProfile = { + nameID: "testuser@samsung.com", + nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", + attributes: { + email: "testuser@samsung.com", + name: "테스트 사용자", + displayName: "Test User Samsung", + // 추가 테스트 속성들 + department: "개발팀", + employeeId: "TEST001", + mobile: "010-1234-5678" + } + }; + + console.log("🎭 Mock SAML Profile created:", { + nameID: mockProfile.nameID, + nameIDFormat: mockProfile.nameIDFormat, + attributeCount: Object.keys(mockProfile.attributes || {}).length, + attributes: Object.keys(mockProfile.attributes || {}), + timestamp: new Date().toISOString(), + }); + + return mockProfile; +} + // SAML 로그아웃 URL 생성 (서버 액션) // 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠. export async function createLogoutRequest(nameID: string): Promise<string> { "use server"; const saml = new SAML(createSAMLConfig()); + // Profile 객체 형태로 전달 + const profile = { nameID }; return await saml.getLogoutUrlAsync( - nameID, + profile, "", // RelayState { nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", |
